Skip to main content

vaultparser/
parser.rs

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
43/// Helper to decrypt a PDF natively with lopdf if it's encrypted
44fn 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        // Native lopdf decryption using load_with_password (handles empty password AES-256)
63        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
95/// Automatically detect column guides by analyzing the horizontal density of text spans
96pub 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    // We divide the horizontal page width into 1000 buckets (0.0 to 1.0)
122    let mut histogram = vec![0usize; 1000];
123    let sample_pages = pages.iter().take(5); // Analyze up to first 5 pages for layout
124    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    // Clean up decrypted file if it was created temporarily
155    if processed_path != path_ref {
156        let _ = std::fs::remove_file(&processed_path);
157    }
158
159    // Find gaps / valleys. A valley is where the occupancy histogram is low.
160    // We only look in the range of 5% to 95% of the page to avoid page margins.
161    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                // Gaps should be at least 1% of the page width (10 buckets)
183                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
208/// Primary function to extract tabular data from a PDF file path
209pub 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        // Handle manual row deletions
326        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        // Handle manual cell edits
334        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        // Skip header rows on page 1
390        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        // Skip footer rows on all pages
395        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    // Clean up decrypted file if it was created temporarily
441    if processed_path != path_ref {
442        let _ = fs::remove_file(&processed_path);
443    }
444
445    // Calculate active indices and headers
446    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
467/// Extract tabular data directly from PDF bytes
468pub fn extract_from_bytes(
469    pdf_bytes: &[u8],
470    config: &ExtractionConfig,
471) -> Result<ExtractedTable, ExtractorError> {
472    // Create a temp file to hold the bytes for parsing
473    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            // Check headers mapping (active column names in uppercase, excluding SKIP)
532            assert_eq!(
533                table.headers,
534                vec![
535                    "DATE",
536                    "DESCRIPTION",
537                    "REFERENCE",
538                    "DEBIT",
539                    "CREDIT",
540                    "BALANCE"
541                ]
542            );
543
544            // Check first extracted row
545            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}