1use std::fs;
2use std::path::{Path, PathBuf};
3use std::time::SystemTime;
4use tracing::{debug, error, info, warn};
5
6use pdfsink_rs::PdfDocument;
7
8use crate::config::ExtractionConfig;
9use crate::error::ExtractorError;
10use crate::models::{ExtractedTable, PageRow, WordItem};
11
12#[derive(Debug, Clone)]
13struct GroupedRow {
14 y: f64,
15 items: Vec<WordItem>,
16}
17
18fn is_possible_date(val: &str) -> bool {
19 if val.is_empty() {
20 return false;
21 }
22 let cleaned = val.trim();
23 if cleaned.len() > 15 {
24 return false;
25 }
26 cleaned.chars().any(|c| c.is_ascii_digit())
27}
28
29fn is_possible_amount(val: &str) -> bool {
30 if val.is_empty() {
31 return false;
32 }
33 let cleaned = val
34 .replace('$', "")
35 .replace('£', "")
36 .replace('€', "")
37 .replace(',', "")
38 .trim()
39 .to_string();
40 cleaned.parse::<f64>().is_ok()
41}
42
43fn decrypt_pdf_if_needed(
45 temp_path: &Path,
46 password: Option<&str>,
47) -> Result<PathBuf, ExtractorError> {
48 let use_decrypted = match PdfDocument::open(temp_path) {
49 Ok(pdf) => pdf.pages().is_empty(),
50 Err(_) => true,
51 };
52
53 if use_decrypted {
54 let decrypted_path = temp_path.with_extension("decrypted.pdf");
55 let pwd = password.unwrap_or("");
56
57 info!(
58 "PDF is encrypted or layout is unreadable. Attempting native decryption for '{}'...",
59 temp_path.display()
60 );
61
62 let mut doc = lopdf::Document::load_with_password(temp_path, pwd).map_err(|e| {
64 error!(
65 "LOPDF decryption failed for PDF '{}': {:?}",
66 temp_path.display(),
67 e
68 );
69 ExtractorError::DecryptError(format!("Failed to load/decrypt PDF with lopdf: {:?}", e))
70 })?;
71
72 if doc.is_encrypted() {
73 doc.trailer.remove(b"Encrypt");
74 }
75
76 doc.save(&decrypted_path).map_err(|e| {
77 error!("Failed to save decrypted PDF copy: {:?}", e);
78 ExtractorError::DecryptError(format!("Failed to save decrypted PDF: {:?}", e))
79 })?;
80
81 info!(
82 "PDF successfully decrypted and saved to '{}'.",
83 decrypted_path.display()
84 );
85 Ok(decrypted_path)
86 } else {
87 debug!(
88 "PDF '{}' is already unencrypted and readable directly.",
89 temp_path.display()
90 );
91 Ok(temp_path.to_path_buf())
92 }
93}
94
95pub fn detect_column_guides<P: AsRef<Path>>(
97 pdf_path: P,
98 password: Option<&str>,
99 y_top_trim: f64,
100 y_bottom_trim: f64,
101) -> Result<Vec<f64>, ExtractorError> {
102 let path_ref = pdf_path.as_ref();
103 info!(
104 "Starting layout analysis to auto-detect columns for '{}'...",
105 path_ref.display()
106 );
107
108 let processed_path = decrypt_pdf_if_needed(path_ref, password)?;
109
110 let pdf = PdfDocument::open(&processed_path).map_err(|e| {
111 error!("Failed to open PDF during layout analysis: {:?}", e);
112 ExtractorError::PdfOpenError(format!("{:?}", e))
113 })?;
114
115 let pages = pdf.pages();
116 if pages.is_empty() {
117 warn!("PDF has no pages. Skipping layout analysis.");
118 return Ok(Vec::new());
119 }
120
121 let mut histogram = vec![0usize; 1000];
123 let sample_pages = pages.iter().take(5); let pages_count = sample_pages.len();
125 debug!(
126 "Analyzing first {} pages for horizontal text distribution...",
127 pages_count
128 );
129
130 for page in sample_pages {
131 let width = page.width as f64;
132 let height = page.height as f64;
133 let top_px = y_top_trim * height;
134 let bottom_px = y_bottom_trim * height;
135
136 let words = page.extract_words();
137 for w in words {
138 if w.top >= top_px && w.top <= bottom_px {
139 let x0_pct = (w.x0 / width).clamp(0.0, 1.0);
140 let x1_pct = (w.x1 / width).clamp(0.0, 1.0);
141
142 let start_bucket = (x0_pct * 1000.0) as usize;
143 let end_bucket = (x1_pct * 1000.0) as usize;
144
145 for bucket in start_bucket..=end_bucket {
146 if bucket < 1000 {
147 histogram[bucket] += 1;
148 }
149 }
150 }
151 }
152 }
153
154 if processed_path != path_ref {
156 let _ = std::fs::remove_file(&processed_path);
157 }
158
159 let mut guides = Vec::new();
162 let mut in_valley = false;
163 let mut valley_start = 0;
164
165 let total_occupancy: usize = histogram.iter().sum();
166 let avg_occupancy = total_occupancy as f64 / 1000.0;
167 let threshold = (avg_occupancy * 0.05).max(1.0) as usize;
168
169 for i in 50..950 {
170 let val = histogram[i];
171 if val <= threshold {
172 if !in_valley {
173 in_valley = true;
174 valley_start = i;
175 }
176 } else {
177 if in_valley {
178 in_valley = false;
179 let valley_end = i - 1;
180 let width = valley_end - valley_start + 1;
181
182 if width >= 10 {
184 let center = (valley_start + valley_end) as f64 / 2000.0;
185 guides.push(center);
186 }
187 }
188 }
189 }
190
191 if in_valley {
192 let valley_end = 949;
193 let width = valley_end - valley_start + 1;
194 if width >= 10 {
195 let center = (valley_start + valley_end) as f64 / 2000.0;
196 guides.push(center);
197 }
198 }
199
200 info!(
201 "Layout analysis complete. Auto-detected {} column delimiters: {:?}",
202 guides.len(),
203 guides
204 );
205 Ok(guides)
206}
207
208pub fn extract_from_file<P: AsRef<Path>>(
210 pdf_path: P,
211 config: &ExtractionConfig,
212) -> Result<ExtractedTable, ExtractorError> {
213 let path_ref = pdf_path.as_ref();
214 info!(
215 "Starting PDF table extraction for '{}'...",
216 path_ref.display()
217 );
218
219 let processed_path = decrypt_pdf_if_needed(path_ref, config.password.as_deref())?;
220
221 let pdf = PdfDocument::open(&processed_path).map_err(|e| {
222 error!("Failed to open PDF document: {:?}", e);
223 ExtractorError::PdfOpenError(format!("{:?}", e))
224 })?;
225
226 let mut all_rows = Vec::new();
227 let pages = pdf.pages();
228
229 for (page_idx, page) in pages.iter().enumerate() {
230 let width = page.width as f64;
231 let height = page.height as f64;
232 let top_px = config.y_top_trim * height;
233 let bottom_px = config.y_bottom_trim * height;
234
235 let words = page.extract_words();
236 let filtered_words: Vec<&pdfsink_rs::Word> = words
237 .iter()
238 .filter(|w| w.top >= top_px && w.top <= bottom_px)
239 .collect();
240
241 if filtered_words.is_empty() {
242 continue;
243 }
244
245 let mut items: Vec<WordItem> = filtered_words
246 .iter()
247 .map(|w| WordItem {
248 text: w.text.clone(),
249 x: w.x0,
250 y: w.top,
251 width: w.x1 - w.x0,
252 })
253 .collect();
254
255 items.sort_by(|a, b| a.y.partial_cmp(&b.y).unwrap_or(std::cmp::Ordering::Equal));
256
257 let mut grouped_rows: Vec<GroupedRow> = Vec::new();
258 for item in items {
259 let mut found_row_idx = None;
260 for (idx, r) in grouped_rows.iter().enumerate() {
261 if (r.y - item.y).abs() <= config.y_tolerance {
262 found_row_idx = Some(idx);
263 break;
264 }
265 }
266 if let Some(idx) = found_row_idx {
267 grouped_rows[idx].items.push(item);
268 let total_y: f64 = grouped_rows[idx].items.iter().map(|it| it.y).sum();
269 grouped_rows[idx].y = total_y / grouped_rows[idx].items.len() as f64;
270 } else {
271 grouped_rows.push(GroupedRow {
272 y: item.y,
273 items: vec![item],
274 });
275 }
276 }
277
278 for r in &mut grouped_rows {
279 r.items
280 .sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(std::cmp::Ordering::Equal));
281 }
282 grouped_rows.sort_by(|a, b| a.y.partial_cmp(&b.y).unwrap_or(std::cmp::Ordering::Equal));
283
284 let page_guides: Vec<f64> = config.col_guides.iter().map(|&g| g * width).collect();
285 let num_cols = page_guides.len() + 1;
286
287 let mut page_rows = Vec::new();
288 for (row_idx, r) in grouped_rows.iter().enumerate() {
289 let mut cell_contents = vec![Vec::new(); num_cols];
290 for item in &r.items {
291 let center_x = item.x + item.width / 2.0;
292 let mut col_idx = 0;
293 while col_idx < page_guides.len() && center_x > page_guides[col_idx] {
294 col_idx += 1;
295 }
296 cell_contents[col_idx].push(item.clone());
297 }
298
299 let mut cells = Vec::new();
300 for items_in_col in &mut cell_contents {
301 items_in_col
302 .sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(std::cmp::Ordering::Equal));
303 let cell_text = items_in_col
304 .iter()
305 .map(|it| it.text.as_str())
306 .collect::<Vec<&str>>()
307 .join(" ")
308 .trim()
309 .to_string();
310 cells.push(cell_text);
311 }
312
313 page_rows.push(PageRow {
314 id: format!("row-{}-{}-{:.2}", page_idx, row_idx, r.y),
315 y: r.y,
316 page: page_idx + 1,
317 cells,
318 });
319 }
320
321 page_rows.retain(|row| row.cells.iter().any(|c| !c.is_empty()));
322
323 let page_str = (page_idx + 1).to_string();
324
325 if let Some(deletes) = config.deleted_rows.get(&page_str) {
327 page_rows.retain(|row| {
328 let y_key = format!("{:.2}", row.y);
329 !deletes.contains_key(&y_key)
330 });
331 }
332
333 if let Some(edits) = config.manual_edits.get(&page_str) {
335 for row in &mut page_rows {
336 let y_key = format!("{:.2}", row.y);
337 if let Some(col_edits) = edits.get(&y_key) {
338 for (col_idx_str, val) in col_edits {
339 if let Ok(col_idx) = col_idx_str.parse::<usize>() {
340 if col_idx < row.cells.len() {
341 row.cells[col_idx] = val.clone();
342 }
343 }
344 }
345 }
346 }
347 }
348
349 let desc_idx = config.col_mappings.iter().position(|m| m == "description");
350 let date_idx = config.col_mappings.iter().position(|m| m == "date");
351 let amount_idx = config.col_mappings.iter().position(|m| m == "amount");
352 let debit_idx = config.col_mappings.iter().position(|m| m == "debit");
353 let credit_idx = config.col_mappings.iter().position(|m| m == "credit");
354 let balance_idx = config.col_mappings.iter().position(|m| m == "balance");
355
356 if config.merge_multi_line {
357 if let Some(desc_idx) = desc_idx {
358 let mut merged = Vec::new();
359 for row in page_rows {
360 let cell_desc = &row.cells[desc_idx];
361 let is_date_empty = date_idx.map_or(true, |idx| row.cells[idx].is_empty());
362 let is_amount_empty = amount_idx.map_or(true, |idx| row.cells[idx].is_empty());
363 let is_debit_empty = debit_idx.map_or(true, |idx| row.cells[idx].is_empty());
364 let is_credit_empty = credit_idx.map_or(true, |idx| row.cells[idx].is_empty());
365 let is_balance_empty =
366 balance_idx.map_or(true, |idx| row.cells[idx].is_empty());
367
368 let is_continuation = is_date_empty
369 && is_amount_empty
370 && is_debit_empty
371 && is_credit_empty
372 && is_balance_empty
373 && !cell_desc.is_empty();
374
375 if is_continuation && !merged.is_empty() {
376 let last_row: &mut PageRow = merged.last_mut().unwrap();
377 last_row.cells[desc_idx] =
378 format!("{} {}", last_row.cells[desc_idx], cell_desc)
379 .trim()
380 .to_string();
381 } else {
382 merged.push(row);
383 }
384 }
385 page_rows = merged;
386 }
387 }
388
389 if page_idx == 0 && config.skip_header_rows > 0 {
391 let skip = config.skip_header_rows.min(page_rows.len());
392 page_rows.drain(0..skip);
393 }
394 if config.skip_footer_rows > 0 {
396 let len = page_rows.len();
397 let skip = config.skip_footer_rows.min(len);
398 page_rows.truncate(len - skip);
399 }
400
401 let mut filtered_rows = Vec::new();
402 for row in page_rows {
403 let mut keep = true;
404 if config.filter_only_date {
405 if let Some(date_idx) = date_idx {
406 if !is_possible_date(&row.cells[date_idx]) {
407 keep = false;
408 }
409 }
410 }
411 if config.filter_only_amount {
412 let mut has_val = false;
413 if let Some(amount_idx) = amount_idx {
414 if is_possible_amount(&row.cells[amount_idx]) {
415 has_val = true;
416 }
417 }
418 if let Some(debit_idx) = debit_idx {
419 if is_possible_amount(&row.cells[debit_idx]) {
420 has_val = true;
421 }
422 }
423 if let Some(credit_idx) = credit_idx {
424 if is_possible_amount(&row.cells[credit_idx]) {
425 has_val = true;
426 }
427 }
428 if !has_val {
429 keep = false;
430 }
431 }
432 if keep {
433 filtered_rows.push(row);
434 }
435 }
436
437 all_rows.extend(filtered_rows);
438 }
439
440 if processed_path != path_ref {
442 let _ = fs::remove_file(&processed_path);
443 }
444
445 let mut active_indices = Vec::new();
447 let mut headers = Vec::new();
448 for (idx, m) in config.col_mappings.iter().enumerate() {
449 if m != "skip" {
450 active_indices.push(idx);
451 headers.push(m.to_uppercase());
452 }
453 }
454
455 info!(
456 "Successfully extracted {} total transaction rows.",
457 all_rows.len()
458 );
459
460 Ok(ExtractedTable {
461 headers,
462 active_indices,
463 rows: all_rows,
464 })
465}
466
467pub fn extract_from_bytes(
469 pdf_bytes: &[u8],
470 config: &ExtractionConfig,
471) -> Result<ExtractedTable, ExtractorError> {
472 let temp_dir = Path::new("temp");
474 fs::create_dir_all(temp_dir)?;
475
476 let ts = SystemTime::now()
477 .duration_since(std::time::UNIX_EPOCH)
478 .map(|d| d.as_millis())
479 .unwrap_or(0);
480 let temp_name = format!("temp_{}.pdf", ts);
481 let temp_path = temp_dir.join(&temp_name);
482
483 fs::write(&temp_path, pdf_bytes)?;
484
485 let result = extract_from_file(&temp_path, config);
486 let _ = fs::remove_file(&temp_path);
487 result
488}
489
490#[cfg(test)]
491mod tests {
492 use super::*;
493 use crate::presets::BankPreset;
494
495 #[test]
496 fn test_is_possible_date() {
497 assert!(is_possible_date("01/02/2023"));
498 assert!(is_possible_date("15-08-1947"));
499 assert!(is_possible_date("2026-07-01"));
500 assert!(!is_possible_date(""));
501 assert!(!is_possible_date("Not a date"));
502 }
503
504 #[test]
505 fn test_is_possible_amount() {
506 assert!(is_possible_amount("123.45"));
507 assert!(is_possible_amount("$1,234.50"));
508 assert!(is_possible_amount("€100"));
509 assert!(is_possible_amount("-50.00"));
510 assert!(!is_possible_amount("abc"));
511 assert!(!is_possible_amount(""));
512 }
513
514 #[test]
515 fn test_sample_hdfc_extraction() {
516 let pdf_path = Path::new("static/sample_hdfc_decrypted.pdf");
517 if pdf_path.exists() {
518 let config = BankPreset::Hdfc.config();
519 let table_res = extract_from_file(pdf_path, &config);
520 assert!(
521 table_res.is_ok(),
522 "Failed to extract from HDFC statement: {:?}",
523 table_res.err()
524 );
525 let table = table_res.unwrap();
526 assert!(
527 !table.rows.is_empty(),
528 "Extracted table should not be empty"
529 );
530
531 assert_eq!(
533 table.headers,
534 vec![
535 "DATE",
536 "DESCRIPTION",
537 "REFERENCE",
538 "DEBIT",
539 "CREDIT",
540 "BALANCE"
541 ]
542 );
543
544 let first_row = &table.rows[0];
546 assert!(
547 !first_row.cells.is_empty(),
548 "First row cells should not be empty"
549 );
550 println!("First Row: {:?}", first_row);
551 } else {
552 eprintln!(
553 "Warning: sample_hdfc_decrypted.pdf not found at static/sample_hdfc_decrypted.pdf, skipping integration test"
554 );
555 }
556 }
557
558 #[test]
559 fn test_encrypted_sample_hdfc_extraction() {
560 let pdf_path = Path::new("static/sample_hdfc.pdf");
561 if pdf_path.exists() {
562 let config = BankPreset::Hdfc.config();
563 let table_res = extract_from_file(pdf_path, &config);
564 assert!(
565 table_res.is_ok(),
566 "Failed to extract from encrypted HDFC statement: {:?}",
567 table_res.err()
568 );
569 let table = table_res.unwrap();
570 assert!(
571 !table.rows.is_empty(),
572 "Extracted table should not be empty"
573 );
574 println!("Encrypted HDFC Statement Headers: {:?}", table.headers);
575 println!("Encrypted HDFC Statement First Row: {:?}", table.rows[0]);
576 println!("Total Rows Extracted from Encrypted: {}", table.rows.len());
577 } else {
578 eprintln!(
579 "Warning: sample_hdfc.pdf not found at static/sample_hdfc.pdf, skipping integration test"
580 );
581 }
582 }
583
584 #[test]
585 fn test_rks_user_charges_extraction() {
586 let pdf_path = Path::new("RKS USER CHARGES bank.pdf");
587 if pdf_path.exists() {
588 let config = BankPreset::Hdfc.config();
589 let table_res = extract_from_file(pdf_path, &config);
590 assert!(
591 table_res.is_ok(),
592 "Failed to extract from RKS statement: {:?}",
593 table_res.err()
594 );
595 let table = table_res.unwrap();
596 assert!(
597 !table.rows.is_empty(),
598 "Extracted table should not be empty"
599 );
600
601 println!("RKS Statement Headers: {:?}", table.headers);
602 println!("RKS Statement First Row: {:?}", table.rows[0]);
603 println!("Total Rows Extracted: {}", table.rows.len());
604 } else {
605 panic!(
606 "Required PDF file 'RKS USER CHARGES bank.pdf' was not found in the root directory!"
607 );
608 }
609 }
610
611 #[test]
612 fn test_detect_column_guides() {
613 let pdf_path = Path::new("static/sample_hdfc_decrypted.pdf");
614 if pdf_path.exists() {
615 let guides_res = detect_column_guides(pdf_path, None, 0.0, 1.0);
616 assert!(
617 guides_res.is_ok(),
618 "Failed to auto-detect guides: {:?}",
619 guides_res.err()
620 );
621 let guides = guides_res.unwrap();
622 assert!(!guides.is_empty(), "Detected guides should not be empty");
623 println!("Auto-detected column guides: {:?}", guides);
624 }
625 }
626}