Skip to main content

vaultparser/
config.rs

1use crate::error::ExtractorError;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5/// Configuration parameters for PDF table extraction.
6///
7/// This struct defines the extraction behaviors, coordinates for column boundaries,
8/// vertical row clustering tolerance, trimming limits, and filters.
9/// Usually constructed using [`ExtractionConfigBuilder`].
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
11pub struct ExtractionConfig {
12    /// X-coordinate column dividers, sorted in ascending order (values between 0.0 and 1.0 relative to page width)
13    pub col_guides: Vec<f64>,
14    /// Column purpose mappings corresponding to the divided columns (length should be col_guides.len() + 1)
15    pub col_mappings: Vec<String>,
16    /// Vertical distance tolerance (in points/pixels) for grouping words on the same line
17    pub y_tolerance: f64,
18    /// Merge lines that only have description text into the preceding row's description
19    pub merge_multi_line: bool,
20    /// Number of rows to skip at the beginning of the first page (e.g. table headers)
21    pub skip_header_rows: usize,
22    /// Number of rows to skip at the bottom of each page (e.g. running footers)
23    pub skip_footer_rows: usize,
24    /// Filter out rows that do not have a recognizable date in the mapped 'date' column
25    pub filter_only_date: bool,
26    /// Filter out rows that do not have a numerical amount in any mapped 'amount', 'debit', or 'credit' columns
27    pub filter_only_amount: bool,
28    /// Page top trim factor (0.0 to 1.0) - contents above this relative Y coordinate are excluded
29    pub y_top_trim: f64,
30    /// Page bottom trim factor (0.0 to 1.0) - contents below this relative Y coordinate are excluded
31    pub y_bottom_trim: f64,
32    /// Manual cell content overrides keyed by page number -> Y-coordinate (formatted) -> column index -> new value
33    #[serde(default)]
34    pub manual_edits: HashMap<String, HashMap<String, HashMap<String, String>>>,
35    /// Manual row deletions keyed by page number -> Y-coordinate (formatted) -> is_deleted
36    #[serde(default)]
37    pub deleted_rows: HashMap<String, HashMap<String, bool>>,
38    /// Optional password to decrypt the PDF document
39    #[serde(default)]
40    pub password: Option<String>,
41    /// Automatically categorize transactions into a Category column (default: false)
42    #[serde(default)]
43    pub categorize: bool,
44    /// Optional start date filter (inclusive) in YYYY-MM-DD or DD-MM-YYYY format
45    #[serde(default)]
46    pub from_date: Option<String>,
47    /// Optional end date filter (inclusive) in YYYY-MM-DD or DD-MM-YYYY format
48    #[serde(default)]
49    pub to_date: Option<String>,
50}
51
52impl Default for ExtractionConfig {
53    fn default() -> Self {
54        Self {
55            col_guides: Vec::new(),
56            col_mappings: Vec::new(),
57            y_tolerance: 6.0,
58            merge_multi_line: true,
59            skip_header_rows: 0,
60            skip_footer_rows: 0,
61            filter_only_date: false,
62            filter_only_amount: false,
63            y_top_trim: 0.0,
64            y_bottom_trim: 1.0,
65            manual_edits: HashMap::new(),
66            deleted_rows: HashMap::new(),
67            password: None,
68            categorize: false,
69            from_date: None,
70            to_date: None,
71        }
72    }
73}
74
75impl ExtractionConfig {
76    /// Returns a new [`ExtractionConfigBuilder`] instance.
77    ///
78    /// # Examples
79    /// ```
80    /// use vaultparser::ExtractionConfig;
81    ///
82    /// let config = ExtractionConfig::builder()
83    ///     .col_guides(vec![0.11, 0.42, 0.52, 0.62, 0.75, 0.88])
84    ///     .col_mappings(vec![
85    ///         "date".to_string(),
86    ///         "description".to_string(),
87    ///         "reference".to_string(),
88    ///         "skip".to_string(),
89    ///         "debit".to_string(),
90    ///         "credit".to_string(),
91    ///         "balance".to_string(),
92    ///     ])
93    ///     .y_tolerance(6.0)
94    ///     .build()
95    ///     .expect("valid configuration");
96    /// assert_eq!(config.col_mappings.len(), config.col_guides.len() + 1);
97    /// ```
98    #[must_use]
99    pub fn builder() -> ExtractionConfigBuilder {
100        ExtractionConfigBuilder::new()
101    }
102
103    /// Validates the configuration parameters.
104    ///
105    /// # Errors
106    /// Returns `ExtractorError::InvalidConfig` if column guides/mappings length, coordinates, or crop bounds are invalid.
107    pub fn validate(&self) -> Result<(), ExtractorError> {
108        if (!self.col_guides.is_empty() || !self.col_mappings.is_empty())
109            && self.col_mappings.len() != self.col_guides.len() + 1
110        {
111            return Err(ExtractorError::InvalidConfig(format!(
112                "Column mappings length ({}) must be equal to column guides length ({}) + 1",
113                self.col_mappings.len(),
114                self.col_guides.len()
115            )));
116        }
117        for (i, &g) in self.col_guides.iter().enumerate() {
118            if !(0.0..=1.0).contains(&g) || g.is_nan() {
119                return Err(ExtractorError::InvalidConfig(format!(
120                    "Column guide at index {} has invalid value {}: must be between 0.0 and 1.0",
121                    i, g
122                )));
123            }
124            if i > 0 && g < self.col_guides[i - 1] {
125                return Err(ExtractorError::InvalidConfig(format!(
126                    "Column guides must be sorted in ascending order (found {} after {})",
127                    g,
128                    self.col_guides[i - 1]
129                )));
130            }
131        }
132        if self.y_tolerance <= 0.0 || self.y_tolerance.is_nan() {
133            return Err(ExtractorError::InvalidConfig(format!(
134                "y_tolerance must be a positive number, found {}",
135                self.y_tolerance
136            )));
137        }
138        if self.y_top_trim < 0.0 || self.y_top_trim > 1.0 || self.y_top_trim.is_nan() {
139            return Err(ExtractorError::InvalidConfig(format!(
140                "y_top_trim must be between 0.0 and 1.0, found {}",
141                self.y_top_trim
142            )));
143        }
144        if self.y_bottom_trim < 0.0 || self.y_bottom_trim > 1.0 || self.y_bottom_trim.is_nan() {
145            return Err(ExtractorError::InvalidConfig(format!(
146                "y_bottom_trim must be between 0.0 and 1.0, found {}",
147                self.y_bottom_trim
148            )));
149        }
150        if self.y_top_trim > self.y_bottom_trim {
151            return Err(ExtractorError::InvalidConfig(format!(
152                "y_top_trim ({}) cannot be greater than y_bottom_trim ({})",
153                self.y_top_trim, self.y_bottom_trim
154            )));
155        }
156        if let Some(from_str) = &self.from_date
157            && parse_date(from_str).is_none()
158        {
159            return Err(ExtractorError::InvalidConfig(format!(
160                "Invalid from_date '{}': expected YYYY-MM-DD or DD-MM-YYYY format",
161                from_str
162            )));
163        }
164        if let Some(to_str) = &self.to_date
165            && parse_date(to_str).is_none()
166        {
167            return Err(ExtractorError::InvalidConfig(format!(
168                "Invalid to_date '{}': expected YYYY-MM-DD or DD-MM-YYYY format",
169                to_str
170            )));
171        }
172        if let (Some(from_str), Some(to_str)) = (&self.from_date, &self.to_date)
173            && let (Some(from_d), Some(to_d)) = (parse_date(from_str), parse_date(to_str))
174            && from_d > to_d
175        {
176            return Err(ExtractorError::InvalidConfig(format!(
177                "from_date ({}) cannot be after to_date ({})",
178                from_str, to_str
179            )));
180        }
181        Ok(())
182    }
183}
184
185/// Helper to parse standard date strings into [`chrono::NaiveDate`].
186pub fn parse_date(s: &str) -> Option<chrono::NaiveDate> {
187    let clean = s.trim();
188    if clean.is_empty() {
189        return None;
190    }
191    if let Ok(d) = chrono::NaiveDate::parse_from_str(clean, "%Y-%m-%d") {
192        return Some(d);
193    }
194    if let Ok(d) = chrono::NaiveDate::parse_from_str(clean, "%d-%m-%Y") {
195        return Some(d);
196    }
197    if let Ok(d) = chrono::NaiveDate::parse_from_str(clean, "%d/%m/%Y") {
198        return Some(d);
199    }
200    if let Ok(d) = chrono::NaiveDate::parse_from_str(clean, "%Y/%m/%d") {
201        return Some(d);
202    }
203    if let Ok(d) = chrono::NaiveDate::parse_from_str(clean, "%d-%b-%Y") {
204        return Some(d);
205    }
206    if let Ok(d) = chrono::NaiveDate::parse_from_str(clean, "%d %b %Y") {
207        return Some(d);
208    }
209    None
210}
211
212/// A builder helper for configuring and creating an [`ExtractionConfig`].
213pub struct ExtractionConfigBuilder {
214    config: ExtractionConfig,
215}
216
217impl Default for ExtractionConfigBuilder {
218    fn default() -> Self {
219        Self::new()
220    }
221}
222
223impl ExtractionConfigBuilder {
224    /// Creates a new `ExtractionConfigBuilder` with default configurations.
225    #[must_use]
226    pub fn new() -> Self {
227        Self {
228            config: ExtractionConfig::default(),
229        }
230    }
231
232    /// Sets the horizontal column division guides (0.0 to 1.0 relative to page width).
233    /// These guides are automatically sorted in ascending order.
234    #[must_use]
235    pub fn col_guides(mut self, mut guides: Vec<f64>) -> Self {
236        guides.sort_by(f64::total_cmp);
237        self.config.col_guides = guides;
238        self
239    }
240
241    /// Sets the column mapping labels corresponding to the divided columns.
242    /// The length of mappings must be equal to `col_guides.len() + 1`.
243    #[must_use]
244    pub fn col_mappings(mut self, mappings: Vec<String>) -> Self {
245        self.config.col_mappings = mappings;
246        self
247    }
248
249    /// Sets the vertical distance tolerance (in points/pixels) for grouping words on the same row line.
250    #[must_use]
251    pub fn y_tolerance(mut self, tolerance: f64) -> Self {
252        self.config.y_tolerance = tolerance;
253        self
254    }
255
256    /// Configures whether to merge description-only lines into the preceding transaction row's description.
257    #[must_use]
258    pub fn merge_multi_line(mut self, merge: bool) -> Self {
259        self.config.merge_multi_line = merge;
260        self
261    }
262
263    /// Sets the number of rows to skip at the top of the first page (useful for skipping main statement headers).
264    #[must_use]
265    pub fn skip_header_rows(mut self, count: usize) -> Self {
266        self.config.skip_header_rows = count;
267        self
268    }
269
270    /// Sets the number of rows to skip at the bottom of each page (useful for skipping page numbers and footers).
271    #[must_use]
272    pub fn skip_footer_rows(mut self, count: usize) -> Self {
273        self.config.skip_footer_rows = count;
274        self
275    }
276
277    /// Configures whether to filter out rows that don't have a valid date in the mapped `date` column.
278    #[must_use]
279    pub fn filter_only_date(mut self, filter: bool) -> Self {
280        self.config.filter_only_date = filter;
281        self
282    }
283
284    /// Configures whether to filter out rows that don't have an amount in any mapped `amount`, `debit`, or `credit` columns.
285    #[must_use]
286    pub fn filter_only_amount(mut self, filter: bool) -> Self {
287        self.config.filter_only_amount = filter;
288        self
289    }
290
291    /// Sets the relative top margin for exclusion (0.0 to 1.0). Content above this is ignored.
292    #[must_use]
293    pub fn y_top_trim(mut self, trim: f64) -> Self {
294        self.config.y_top_trim = trim;
295        self
296    }
297
298    /// Sets the relative bottom margin for exclusion (0.0 to 1.0). Content below this is ignored.
299    #[must_use]
300    pub fn y_bottom_trim(mut self, trim: f64) -> Self {
301        self.config.y_bottom_trim = trim;
302        self
303    }
304
305    /// Applies manual cell content edits/overrides.
306    #[must_use]
307    pub fn manual_edits(
308        mut self,
309        edits: HashMap<String, HashMap<String, HashMap<String, String>>>,
310    ) -> Self {
311        self.config.manual_edits = edits;
312        self
313    }
314
315    /// Applies manual row deletions.
316    #[must_use]
317    pub fn deleted_rows(mut self, deleted: HashMap<String, HashMap<String, bool>>) -> Self {
318        self.config.deleted_rows = deleted;
319        self
320    }
321
322    /// Sets the decryption password for the PDF document.
323    #[must_use]
324    pub fn password(mut self, password: Option<String>) -> Self {
325        self.config.password = password;
326        self
327    }
328
329    /// Configures whether to automatically categorize transactions (default: false).
330    #[must_use]
331    pub fn categorize(mut self, categorize: bool) -> Self {
332        self.config.categorize = categorize;
333        self
334    }
335
336    /// Sets the inclusive start date filter (e.g. "2023-01-01" or "01-01-2023").
337    #[must_use]
338    pub fn from_date(mut self, from_date: Option<String>) -> Self {
339        self.config.from_date = from_date;
340        self
341    }
342
343    /// Sets the inclusive end date filter (e.g. "2023-12-31" or "31-12-2023").
344    #[must_use]
345    pub fn to_date(mut self, to_date: Option<String>) -> Self {
346        self.config.to_date = to_date;
347        self
348    }
349
350    /// Builds and returns the configured [`ExtractionConfig`].
351    ///
352    /// # Errors
353    /// Returns `ExtractorError::InvalidConfig` if the configuration is invalid.
354    ///
355    /// # Examples
356    /// ```
357    /// use vaultparser::ExtractionConfig;
358    ///
359    /// // Mappings must be exactly one more than guides.
360    /// let result = ExtractionConfig::builder()
361    ///     .col_guides(vec![0.2, 0.5])
362    ///     .col_mappings(vec!["date".to_string()])
363    ///     .build();
364    /// assert!(result.is_err());
365    /// ```
366    pub fn build(self) -> Result<ExtractionConfig, ExtractorError> {
367        self.config.validate()?;
368        Ok(self.config)
369    }
370}