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}