Skip to main content

oxidize_pdf/text/invoice/
extractor.rs

1//! Invoice data extractor
2//!
3//! This module provides the main `InvoiceExtractor` type for extracting structured
4//! data from invoice PDFs using pattern matching and confidence scoring.
5//!
6//! # Architecture
7//!
8//! The extraction process follows a pipeline:
9//!
10//! ```text
11//! TextFragments → Text Reconstruction → Pattern Matching → Type Conversion → InvoiceData
12//! ```
13//!
14//! 1. **Text Reconstruction**: Join text fragments with spatial awareness
15//! 2. **Pattern Matching**: Apply language-specific regex patterns
16//! 3. **Confidence Scoring**: Calculate confidence for each match (0.0-1.0)
17//! 4. **Type Conversion**: Convert strings to typed fields (amounts, dates, etc.)
18//! 5. **Filtering**: Remove low-confidence matches below threshold
19//!
20//! # Usage
21//!
22//! ```ignore
23//! use oxidize_pdf::text::extraction::{TextExtractor, ExtractionOptions};
24//! use oxidize_pdf::text::invoice::InvoiceExtractor;
25//! use oxidize_pdf::Document;
26//!
27//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
28//! // Extract text from PDF
29//! let doc = Document::open("invoice.pdf")?;
30//! let page = doc.get_page(1)?;
31//! let text_extractor = TextExtractor::new();
32//! let extracted = text_extractor.extract_text(&doc, page, &ExtractionOptions::default())?;
33//!
34//! // Extract invoice data
35//! let extractor = InvoiceExtractor::builder()
36//!     .with_language("es")
37//!     .confidence_threshold(0.7)
38//!     .build();
39//!
40//! let invoice = extractor.extract(&extracted.fragments)?;
41//! println!("Found {} fields", invoice.field_count());
42//! # Ok(())
43//! # }
44//! ```
45//!
46//! # Confidence Scoring
47//!
48//! Each extracted field has a confidence score (0.0 = no confidence, 1.0 = certain):
49//!
50//! - **0.9**: Critical fields (invoice number, total amount)
51//! - **0.8**: Important fields (dates, tax amounts)
52//! - **0.7**: Standard fields (VAT numbers, names)
53//!
54//! Fields below the confidence threshold are automatically filtered out.
55
56use super::error::{ExtractionError, Result};
57use super::patterns::{InvoiceFieldType, PatternLibrary};
58use super::types::{
59    BoundingBox, ExtractedField, InvoiceData, InvoiceField, InvoiceMetadata, Language,
60};
61use super::validators;
62use crate::text::extraction::TextFragment;
63
64/// Invoice data extractor with configurable pattern matching
65///
66/// This is the main entry point for invoice extraction. Use the builder pattern
67/// to configure language, confidence thresholds, and other options.
68///
69/// # Examples
70///
71/// ```
72/// use oxidize_pdf::text::invoice::InvoiceExtractor;
73///
74/// // Spanish invoices with high confidence threshold and kerning-aware spacing
75/// let extractor = InvoiceExtractor::builder()
76///     .with_language("es")
77///     .confidence_threshold(0.85)
78///     .use_kerning(true)  // Enables font-aware spacing in text reconstruction
79///     .build();
80/// ```
81///
82/// # Thread Safety
83///
84/// `InvoiceExtractor` is immutable after construction and can be safely shared
85/// across threads. Consider creating one extractor per language and reusing it.
86pub struct InvoiceExtractor {
87    pattern_library: PatternLibrary,
88    confidence_threshold: f64,
89    /// Enable kerning-aware text reconstruction
90    ///
91    /// When enabled, adjusts inter-fragment spacing based on font continuity.
92    /// Fragments with the same font use tighter spacing (single space), while
93    /// font changes use normal spacing (double space).
94    ///
95    /// **Implementation Note**: This is a simplified version of true kerning.
96    /// Full kerning with font metrics requires access to kerning pair tables,
97    /// which would require passing `font_cache` or `Document` reference.
98    /// The current implementation provides spacing improvements without
99    /// breaking API compatibility.
100    use_kerning: bool,
101    language: Option<Language>,
102}
103
104impl InvoiceExtractor {
105    /// Create a new builder for configuring the extractor
106    ///
107    /// This is the recommended way to create an `InvoiceExtractor`.
108    ///
109    /// # Examples
110    ///
111    /// ```
112    /// use oxidize_pdf::text::invoice::InvoiceExtractor;
113    ///
114    /// let extractor = InvoiceExtractor::builder()
115    ///     .with_language("es")
116    ///     .confidence_threshold(0.8)
117    ///     .build();
118    /// ```
119    pub fn builder() -> InvoiceExtractorBuilder {
120        InvoiceExtractorBuilder::new()
121    }
122
123    /// Extract structured invoice data from text fragments
124    ///
125    /// This is the main extraction method. It processes text fragments from a PDF page
126    /// and returns structured invoice data with confidence scores.
127    ///
128    /// # Process
129    ///
130    /// 1. Text fragments are reconstructed into full text
131    /// 2. Language-specific patterns are applied
132    /// 3. Matches are converted to typed fields
133    /// 4. Confidence scores are calculated
134    /// 5. Low-confidence fields are filtered out
135    ///
136    /// # Arguments
137    ///
138    /// * `text_fragments` - Text fragments extracted from PDF page (from `TextExtractor`)
139    ///
140    /// # Returns
141    ///
142    /// Returns `Ok(InvoiceData)` with extracted fields, or `Err` if:
143    /// - No text fragments provided
144    /// - PDF page is empty
145    /// - Text extraction failed
146    ///
147    /// # Examples
148    ///
149    /// ```ignore
150    /// use oxidize_pdf::text::extraction::{TextExtractor, ExtractionOptions};
151    /// use oxidize_pdf::text::invoice::InvoiceExtractor;
152    /// use oxidize_pdf::Document;
153    ///
154    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
155    /// let doc = Document::open("invoice.pdf")?;
156    /// let page = doc.get_page(1)?;
157    ///
158    /// // Extract text
159    /// let text_extractor = TextExtractor::new();
160    /// let extracted = text_extractor.extract_text(&doc, page, &ExtractionOptions::default())?;
161    ///
162    /// // Extract invoice data
163    /// let extractor = InvoiceExtractor::builder()
164    ///     .with_language("es")
165    ///     .build();
166    ///
167    /// let invoice = extractor.extract(&extracted.fragments)?;
168    ///
169    /// // Access extracted fields
170    /// for field in &invoice.fields {
171    ///     println!("{}: {:?} (confidence: {:.2})",
172    ///         field.field_type.name(),
173    ///         field.field_type,
174    ///         field.confidence
175    ///     );
176    /// }
177    /// # Ok(())
178    /// # }
179    /// ```
180    ///
181    /// # Performance
182    ///
183    /// Extraction is CPU-bound and typically completes in <100ms for standard invoices.
184    /// The extractor can be safely reused across multiple pages and threads.
185    pub fn extract(&self, text_fragments: &[TextFragment]) -> Result<InvoiceData> {
186        if text_fragments.is_empty() {
187            return Err(ExtractionError::NoTextFound(1));
188        }
189
190        // Step 1: Reconstruct full text with position tracking
191        let full_text = self.reconstruct_text(text_fragments);
192
193        // Step 2: Apply pattern matching
194        let matches = self.pattern_library.match_text(&full_text);
195
196        // Step 3: Convert matches to ExtractedField with proper types
197        let mut fields = Vec::new();
198        for (field_type, matched_value, base_confidence) in matches {
199            // Calculate confidence score with context
200            let confidence =
201                self.calculate_confidence(&field_type, base_confidence, &matched_value, &full_text);
202
203            // Skip fields below threshold
204            if confidence < self.confidence_threshold {
205                continue;
206            }
207
208            // Find position of this match in fragments
209            let position = self.find_match_position(&matched_value, text_fragments);
210
211            // Convert to proper InvoiceField with typed data
212            if let Some(invoice_field) = self.convert_to_invoice_field(field_type, &matched_value) {
213                fields.push(ExtractedField::new(
214                    invoice_field,
215                    confidence,
216                    position,
217                    matched_value,
218                ));
219            }
220        }
221
222        // Step 4: Calculate overall confidence
223        let overall_confidence = if fields.is_empty() {
224            0.0
225        } else {
226            fields.iter().map(|f| f.confidence).sum::<f64>() / fields.len() as f64
227        };
228
229        // Step 5: Create metadata
230        let metadata = InvoiceMetadata::new(1, overall_confidence)
231            .with_language(self.language.unwrap_or(Language::English));
232
233        Ok(InvoiceData::new(fields, metadata))
234    }
235
236    /// Extract invoice data from plain text (convenience method for testing)
237    ///
238    /// This is a convenience wrapper around `extract()` that creates synthetic
239    /// TextFragment objects from plain text input. Primarily useful for testing
240    /// and simple scenarios where you don't have actual PDF text fragments.
241    ///
242    /// **Note**: This method creates fragments without position information,
243    /// so proximity-based scoring may be less accurate than with real PDF fragments.
244    ///
245    /// # Arguments
246    ///
247    /// * `text` - Plain text string to extract invoice data from
248    ///
249    /// # Returns
250    ///
251    /// Returns `Ok(InvoiceData)` with extracted fields, or `Err` if text is empty
252    ///
253    /// # Examples
254    ///
255    /// ```
256    /// use oxidize_pdf::text::invoice::InvoiceExtractor;
257    ///
258    /// let extractor = InvoiceExtractor::builder()
259    ///     .with_language("en")
260    ///     .confidence_threshold(0.7)
261    ///     .build();
262    ///
263    /// let invoice_text = "Invoice Number: INV-001\nTotal: £100.00";
264    /// let result = extractor.extract_from_text(invoice_text)?;
265    ///
266    /// assert!(!result.fields.is_empty());
267    /// # Ok::<(), Box<dyn std::error::Error>>(())
268    /// ```
269    pub fn extract_from_text(&self, text: &str) -> Result<InvoiceData> {
270        if text.is_empty() {
271            return Err(ExtractionError::NoTextFound(1));
272        }
273
274        // Create a single synthetic TextFragment from the text
275        let fragment = TextFragment {
276            text: text.to_string(),
277            x: 0.0,
278            y: 0.0,
279            width: 0.0,
280            height: 12.0,
281            font_size: 12.0,
282            font_name: None,
283            is_bold: false,
284            is_italic: false,
285            color: None,
286            space_decisions: Vec::new(),
287            mcid: None,
288            struct_tag: None,
289        };
290
291        // Use the standard extract method
292        self.extract(&[fragment])
293    }
294
295    /// Reconstruct text from fragments
296    ///
297    /// When `use_kerning` is enabled, applies tighter spacing between fragments
298    /// that share the same font, simulating kerning-aware text reconstruction.
299    ///
300    /// **Implementation**: While full kerning requires font metrics (kerning pairs),
301    /// this simplified version adjusts inter-fragment spacing based on font continuity.
302    /// Fragments with the same font get minimal spacing (single space), while font
303    /// changes get normal spacing (double space).
304    fn reconstruct_text(&self, fragments: &[TextFragment]) -> String {
305        if fragments.is_empty() {
306            return String::new();
307        }
308
309        if !self.use_kerning {
310            // Default: join all fragments with single space
311            return fragments
312                .iter()
313                .map(|f| f.text.as_str())
314                .collect::<Vec<_>>()
315                .join(" ");
316        }
317
318        // Kerning-aware: use tighter spacing for same-font fragments
319        let mut result = String::with_capacity(
320            fragments.iter().map(|f| f.text.len()).sum::<usize>() + fragments.len(),
321        );
322
323        for (i, fragment) in fragments.iter().enumerate() {
324            result.push_str(&fragment.text);
325
326            // Add spacing between fragments
327            if i < fragments.len() - 1 {
328                let next = &fragments[i + 1];
329
330                // If both fragments have same font, use minimal spacing
331                // Otherwise use normal spacing for font transitions
332                let spacing = match (&fragment.font_name, &next.font_name) {
333                    (Some(f1), Some(f2)) if f1 == f2 => " ", // Same font: tight spacing
334                    _ => "  ", // Different/unknown font: normal spacing
335                };
336
337                result.push_str(spacing);
338            }
339        }
340
341        result
342    }
343
344    /// Parse amount with language-aware decimal handling
345    fn parse_amount(&self, value: &str) -> Option<f64> {
346        // Determine decimal format based on language
347        let uses_european_format = matches!(
348            self.language,
349            Some(Language::Spanish) | Some(Language::German) | Some(Language::Italian)
350        );
351
352        let normalized = if uses_european_format {
353            // European format: 1.234,56 → remove dots (thousands), replace comma with dot (decimal)
354            value.replace('.', "").replace(',', ".")
355        } else {
356            // US/UK format: 1,234.56 → remove commas (thousands), dot is already decimal
357            value.replace(',', "")
358        };
359
360        normalized.parse::<f64>().ok()
361    }
362
363    /// Calculate confidence score for a match using multi-factor scoring
364    ///
365    /// Combines multiple factors to produce a final confidence score:
366    /// 1. **Base Pattern Confidence** (0.7-0.9): From pattern matching quality
367    /// 2. **Value Validation Bonus** (-0.5 to +0.2): Format and content validation
368    /// 3. **Proximity Bonus** (0.0 to +0.15): Distance from field label keywords
369    ///
370    /// # Arguments
371    ///
372    /// * `field_type` - The type of field being scored (affects which validator is applied)
373    /// * `base_confidence` - Initial confidence from pattern match quality
374    /// * `matched_value` - The extracted value (used for validation)
375    /// * `full_text` - Complete text of the invoice (used for proximity calculation)
376    ///
377    /// # Returns
378    ///
379    /// Final confidence score clamped to [0.0, 1.0]
380    ///
381    /// # Examples
382    ///
383    /// ```ignore
384    /// // Invoice date with valid format gets validation bonus
385    /// let confidence = extractor.calculate_confidence(
386    ///     &InvoiceFieldType::InvoiceDate,
387    ///     0.85,  // base from pattern
388    ///     "20/01/2025",
389    ///     full_text
390    /// );
391    /// // Result: 0.85 + 0.20 (valid date) + proximity = ~1.0
392    /// ```
393    fn calculate_confidence(
394        &self,
395        field_type: &InvoiceFieldType,
396        base_confidence: f64,
397        matched_value: &str,
398        full_text: &str,
399    ) -> f64 {
400        // Start with base confidence from pattern matching
401        let mut score = base_confidence;
402
403        // Apply value validation adjustments based on field type
404        let validation_adjustment = match field_type {
405            InvoiceFieldType::InvoiceDate | InvoiceFieldType::DueDate => {
406                validators::validate_date(matched_value)
407            }
408            InvoiceFieldType::TotalAmount
409            | InvoiceFieldType::TaxAmount
410            | InvoiceFieldType::NetAmount
411            | InvoiceFieldType::LineItemUnitPrice => validators::validate_amount(matched_value),
412            InvoiceFieldType::InvoiceNumber => validators::validate_invoice_number(matched_value),
413            InvoiceFieldType::VatNumber => validators::validate_vat_number(matched_value),
414            // No validators yet for these fields
415            InvoiceFieldType::SupplierName
416            | InvoiceFieldType::CustomerName
417            | InvoiceFieldType::Currency
418            | InvoiceFieldType::ArticleNumber
419            | InvoiceFieldType::LineItemDescription
420            | InvoiceFieldType::LineItemQuantity => 0.0,
421        };
422
423        score += validation_adjustment;
424
425        // Apply proximity bonus (closeness to field label in text)
426        let proximity_bonus = self.calculate_proximity_bonus(field_type, matched_value, full_text);
427        score += proximity_bonus;
428
429        // Clamp to valid range [0.0, 1.0]
430        score.clamp(0.0, 1.0)
431    }
432
433    /// Calculate proximity bonus based on distance from field label keywords
434    ///
435    /// Fields that appear close to their expected label keywords receive a bonus.
436    /// This helps distinguish between correct matches and ambiguous values that
437    /// happen to match the pattern but appear in the wrong context.
438    ///
439    /// # Proximity Bonus Scale
440    ///
441    /// - **+0.15**: Keyword within 20 characters of match
442    /// - **+0.10**: Keyword within 50 characters
443    /// - **+0.05**: Keyword within 100 characters
444    /// - **0.00**: Keyword beyond 100 characters or not found
445    ///
446    /// # Arguments
447    ///
448    /// * `field_type` - The type of field (determines which keywords to search for)
449    /// * `matched_value` - The extracted value
450    /// * `full_text` - Complete invoice text
451    ///
452    /// # Returns
453    ///
454    /// Proximity bonus in range [0.0, 0.15]
455    fn calculate_proximity_bonus(
456        &self,
457        field_type: &InvoiceFieldType,
458        matched_value: &str,
459        full_text: &str,
460    ) -> f64 {
461        // Define keywords for each field type (language-agnostic where possible)
462        let keywords: Vec<&str> = match field_type {
463            InvoiceFieldType::InvoiceNumber => {
464                vec![
465                    "Invoice", "Factura", "Rechnung", "Fattura", "Number", "Número", "Nr",
466                ]
467            }
468            InvoiceFieldType::InvoiceDate => {
469                vec!["Date", "Fecha", "Datum", "Data", "Invoice Date"]
470            }
471            InvoiceFieldType::DueDate => {
472                vec!["Due", "Vencimiento", "Fällig", "Scadenza", "Payment"]
473            }
474            InvoiceFieldType::TotalAmount => {
475                vec![
476                    "Total",
477                    "Grand Total",
478                    "Amount Due",
479                    "Gesamtbetrag",
480                    "Totale",
481                ]
482            }
483            InvoiceFieldType::TaxAmount => {
484                vec!["VAT", "IVA", "MwSt", "Tax", "Impuesto"]
485            }
486            InvoiceFieldType::NetAmount => {
487                vec![
488                    "Subtotal",
489                    "Net",
490                    "Neto",
491                    "Nettobetrag",
492                    "Imponibile",
493                    "Base",
494                ]
495            }
496            InvoiceFieldType::VatNumber => {
497                vec!["VAT", "CIF", "NIF", "USt", "Partita IVA", "Tax ID"]
498            }
499            InvoiceFieldType::CustomerName => {
500                vec!["Bill to", "Customer", "Client", "Cliente"]
501            }
502            InvoiceFieldType::SupplierName => {
503                vec!["From", "Supplier", "Vendor", "Proveedor"]
504            }
505            _ => return 0.0, // No proximity bonus for other fields
506        };
507
508        // Find the matched value position in full text
509        let match_pos = match full_text.find(matched_value) {
510            Some(pos) => pos,
511            None => return 0.0, // Value not found in text (shouldn't happen)
512        };
513
514        // Find the closest keyword and calculate distance
515        let mut min_distance = usize::MAX;
516        for keyword in keywords {
517            // Case-insensitive search
518            let text_lower = full_text.to_lowercase();
519            let keyword_lower = keyword.to_lowercase();
520
521            if let Some(keyword_pos) = text_lower.find(&keyword_lower) {
522                let distance = if keyword_pos < match_pos {
523                    match_pos - keyword_pos
524                } else {
525                    keyword_pos - match_pos
526                };
527
528                min_distance = min_distance.min(distance);
529            }
530        }
531
532        // Award bonus based on proximity (distance in characters)
533        match min_distance {
534            0..=20 => 0.15,   // Very close (same line, adjacent)
535            21..=50 => 0.10,  // Close (nearby in layout)
536            51..=100 => 0.05, // Moderately close
537            _ => 0.0,         // Too far or not found
538        }
539    }
540
541    /// Find the bounding box of a matched value in the fragments
542    fn find_match_position(&self, matched_value: &str, fragments: &[TextFragment]) -> BoundingBox {
543        // Simple approach: find first fragment containing the value
544        for fragment in fragments {
545            if fragment.text.contains(matched_value) {
546                return BoundingBox::new(fragment.x, fragment.y, fragment.width, fragment.height);
547            }
548        }
549
550        // Fallback: use first fragment's position
551        if let Some(first) = fragments.first() {
552            BoundingBox::new(first.x, first.y, first.width, first.height)
553        } else {
554            BoundingBox::new(0.0, 0.0, 0.0, 0.0)
555        }
556    }
557
558    /// Convert field type and string value to typed InvoiceField
559    fn convert_to_invoice_field(
560        &self,
561        field_type: InvoiceFieldType,
562        value: &str,
563    ) -> Option<InvoiceField> {
564        match field_type {
565            InvoiceFieldType::InvoiceNumber => Some(InvoiceField::InvoiceNumber(value.to_string())),
566            InvoiceFieldType::InvoiceDate => Some(InvoiceField::InvoiceDate(value.to_string())),
567            InvoiceFieldType::DueDate => Some(InvoiceField::DueDate(value.to_string())),
568            InvoiceFieldType::TotalAmount => {
569                self.parse_amount(value).map(InvoiceField::TotalAmount)
570            }
571            InvoiceFieldType::TaxAmount => self.parse_amount(value).map(InvoiceField::TaxAmount),
572            InvoiceFieldType::NetAmount => self.parse_amount(value).map(InvoiceField::NetAmount),
573            InvoiceFieldType::VatNumber => Some(InvoiceField::VatNumber(value.to_string())),
574            InvoiceFieldType::SupplierName => Some(InvoiceField::SupplierName(value.to_string())),
575            InvoiceFieldType::CustomerName => Some(InvoiceField::CustomerName(value.to_string())),
576            InvoiceFieldType::Currency => Some(InvoiceField::Currency(value.to_string())),
577            InvoiceFieldType::ArticleNumber => Some(InvoiceField::ArticleNumber(value.to_string())),
578            InvoiceFieldType::LineItemDescription => {
579                Some(InvoiceField::LineItemDescription(value.to_string()))
580            }
581            InvoiceFieldType::LineItemQuantity => {
582                self.parse_amount(value).map(InvoiceField::LineItemQuantity)
583            }
584            InvoiceFieldType::LineItemUnitPrice => self
585                .parse_amount(value)
586                .map(InvoiceField::LineItemUnitPrice),
587        }
588    }
589}
590
591/// Builder for configuring `InvoiceExtractor`
592///
593/// Provides a fluent API for configuring extraction behavior. All settings
594/// have sensible defaults for immediate use.
595///
596/// # Defaults
597///
598/// - **Language**: None (uses default patterns)
599/// - **Confidence Threshold**: 0.7 (70%)
600/// - **Use Kerning**: true (stored but not yet functional - see `use_kerning()` docs)
601///
602/// # Examples
603///
604/// ```
605/// use oxidize_pdf::text::invoice::InvoiceExtractor;
606///
607/// // Minimal configuration
608/// let extractor = InvoiceExtractor::builder()
609///     .with_language("es")
610///     .build();
611///
612/// // Full configuration
613/// let extractor = InvoiceExtractor::builder()
614///     .with_language("de")
615///     .confidence_threshold(0.85)
616///     .use_kerning(false)
617///     .build();
618/// ```
619pub struct InvoiceExtractorBuilder {
620    language: Option<Language>,
621    confidence_threshold: f64,
622    use_kerning: bool,
623    custom_patterns: Option<PatternLibrary>,
624}
625
626impl InvoiceExtractorBuilder {
627    /// Create a new builder with default settings
628    ///
629    /// Defaults:
630    /// - No language (uses English patterns)
631    /// - Confidence threshold: 0.7
632    /// - Kerning: enabled
633    pub fn new() -> Self {
634        Self {
635            language: None,
636            confidence_threshold: 0.7,
637            use_kerning: true,
638            custom_patterns: None,
639        }
640    }
641
642    /// Set the language for pattern matching
643    ///
644    /// Accepts language codes: "es", "en", "de", "it"
645    ///
646    /// # Examples
647    ///
648    /// ```
649    /// use oxidize_pdf::text::invoice::InvoiceExtractor;
650    ///
651    /// let extractor = InvoiceExtractor::builder()
652    ///     .with_language("es")  // Spanish patterns
653    ///     .build();
654    /// ```
655    pub fn with_language(mut self, lang: &str) -> Self {
656        self.language = Language::from_code(lang);
657        self
658    }
659
660    /// Set the minimum confidence threshold (0.0 to 1.0)
661    ///
662    /// Fields below this threshold are filtered out. Higher values reduce
663    /// false positives but may miss valid fields.
664    ///
665    /// Recommended values:
666    /// - **0.5**: Maximum recall (may include false positives)
667    /// - **0.7**: Balanced (default)
668    /// - **0.9**: Maximum precision (may miss valid fields)
669    ///
670    /// # Examples
671    ///
672    /// ```
673    /// use oxidize_pdf::text::invoice::InvoiceExtractor;
674    ///
675    /// // High precision mode
676    /// let extractor = InvoiceExtractor::builder()
677    ///     .confidence_threshold(0.9)
678    ///     .build();
679    /// ```
680    ///
681    /// # Validation
682    ///
683    /// The threshold is automatically clamped to the valid range [0.0, 1.0].
684    /// Values outside this range are silently adjusted to the nearest valid value.
685    pub fn confidence_threshold(mut self, threshold: f64) -> Self {
686        self.confidence_threshold = threshold.clamp(0.0, 1.0);
687        self
688    }
689
690    /// Enable or disable kerning-aware text positioning (PLANNED for v2.0)
691    ///
692    /// **Current Behavior**: This flag is stored but NOT yet used in extraction logic.
693    ///
694    /// **Planned Feature** (v2.0): When enabled, text reconstruction will use actual
695    /// font kerning pairs to calculate accurate character spacing, improving pattern
696    /// matching for invoices with tight kerning (e.g., "AV", "To").
697    ///
698    /// **Why Not Implemented**: Requires architectural changes to expose font metadata
699    /// in `TextFragment`. See struct documentation for technical details.
700    ///
701    /// # Examples
702    ///
703    /// ```
704    /// use oxidize_pdf::text::invoice::InvoiceExtractor;
705    ///
706    /// // Enable for future use (no effect in v1.x)
707    /// let extractor = InvoiceExtractor::builder()
708    ///     .use_kerning(true)  // ⚠️ Stored but not yet functional
709    ///     .build();
710    /// ```
711    pub fn use_kerning(mut self, enabled: bool) -> Self {
712        self.use_kerning = enabled;
713        self
714    }
715
716    /// Use a custom pattern library instead of language-based defaults
717    ///
718    /// Allows complete control over invoice pattern matching by providing a
719    /// custom `PatternLibrary`. Useful for specialized invoice formats or
720    /// combining default patterns with custom additions.
721    ///
722    /// **Note**: When using custom patterns, the `with_language()` setting is ignored.
723    ///
724    /// # Examples
725    ///
726    /// **Example 1: Use default patterns and add custom ones**
727    /// ```
728    /// use oxidize_pdf::text::invoice::{InvoiceExtractor, PatternLibrary, FieldPattern, InvoiceFieldType, Language};
729    ///
730    /// // Start with Spanish defaults
731    /// let mut patterns = PatternLibrary::default_spanish();
732    ///
733    /// // Add custom pattern for your specific invoice format
734    /// patterns.add_pattern(
735    ///     FieldPattern::new(
736    ///         InvoiceFieldType::InvoiceNumber,
737    ///         r"Ref:\s*([A-Z0-9\-]+)",  // Your custom format
738    ///         0.85,
739    ///         Some(Language::Spanish)
740    ///     ).unwrap()
741    /// );
742    ///
743    /// let extractor = InvoiceExtractor::builder()
744    ///     .with_custom_patterns(patterns)
745    ///     .build();
746    /// ```
747    ///
748    /// **Example 2: Build completely custom pattern library**
749    /// ```
750    /// use oxidize_pdf::text::invoice::{InvoiceExtractor, PatternLibrary, FieldPattern, InvoiceFieldType, Language};
751    ///
752    /// let mut patterns = PatternLibrary::new();
753    ///
754    /// // Add only the patterns you need
755    /// patterns.add_pattern(
756    ///     FieldPattern::new(
757    ///         InvoiceFieldType::InvoiceNumber,
758    ///         r"Order\s+#([0-9]+)",
759    ///         0.9,
760    ///         None  // Language-agnostic
761    ///     ).unwrap()
762    /// );
763    ///
764    /// let extractor = InvoiceExtractor::builder()
765    ///     .with_custom_patterns(patterns)
766    ///     .confidence_threshold(0.8)
767    ///     .build();
768    /// ```
769    pub fn with_custom_patterns(mut self, patterns: PatternLibrary) -> Self {
770        self.custom_patterns = Some(patterns);
771        self
772    }
773
774    /// Build the InvoiceExtractor
775    pub fn build(self) -> InvoiceExtractor {
776        // Use custom patterns if provided, otherwise create from language
777        let pattern_library = if let Some(custom) = self.custom_patterns {
778            custom
779        } else if let Some(lang) = self.language {
780            PatternLibrary::with_language(lang)
781        } else {
782            PatternLibrary::new()
783        };
784
785        InvoiceExtractor {
786            pattern_library,
787            confidence_threshold: self.confidence_threshold,
788            use_kerning: self.use_kerning,
789            language: self.language,
790        }
791    }
792}
793
794impl Default for InvoiceExtractorBuilder {
795    fn default() -> Self {
796        Self::new()
797    }
798}
799
800#[cfg(test)]
801mod tests {
802    use super::*;
803
804    #[test]
805    fn test_builder_defaults() {
806        let extractor = InvoiceExtractor::builder().build();
807        assert_eq!(extractor.confidence_threshold, 0.7);
808        assert!(extractor.use_kerning);
809        assert!(extractor.language.is_none());
810    }
811
812    #[test]
813    fn test_builder_with_language() {
814        let extractor = InvoiceExtractor::builder().with_language("es").build();
815        assert_eq!(extractor.language, Some(Language::Spanish));
816    }
817
818    #[test]
819    fn test_builder_confidence_threshold() {
820        let extractor = InvoiceExtractor::builder()
821            .confidence_threshold(0.9)
822            .build();
823        assert_eq!(extractor.confidence_threshold, 0.9);
824    }
825
826    #[test]
827    fn test_builder_use_kerning() {
828        let extractor = InvoiceExtractor::builder().use_kerning(false).build();
829        assert!(!extractor.use_kerning);
830    }
831
832    #[test]
833    fn test_use_kerning_stored_for_future_use() {
834        // Verify the flag is stored correctly (even though not yet functional)
835        let extractor_enabled = InvoiceExtractor::builder().use_kerning(true).build();
836        assert!(
837            extractor_enabled.use_kerning,
838            "use_kerning should be stored as true"
839        );
840
841        let extractor_disabled = InvoiceExtractor::builder().use_kerning(false).build();
842        assert!(
843            !extractor_disabled.use_kerning,
844            "use_kerning should be stored as false"
845        );
846
847        // Default value
848        let extractor_default = InvoiceExtractor::builder().build();
849        assert!(
850            extractor_default.use_kerning,
851            "use_kerning should default to true"
852        );
853    }
854}